Skip to content

fix(security): bind the extension control channel to loopback and gate its handshake - #53

Merged
aaronjmars merged 1 commit into
mainfrom
security/ws-loopback-bind
Jul 20, 2026
Merged

fix(security): bind the extension control channel to loopback and gate its handshake#53
aaronjmars merged 1 commit into
mainfrom
security/ws-loopback-bind

Conversation

@aaronjmars

Copy link
Copy Markdown
Collaborator

fix(security): bind the extension control channel to loopback and gate its handshake

The WebSocket server was created as new WebSocket.Server({ port: WS_PORT }).
With no host, Node listens on all interfaces, so the browser-automation
control channel was reachable from the LAN. The connection handler hands
chromeExtensionSocket to whoever connects last, closing the previous socket,
so any reachable peer could evict the real extension, receive every automation
command the MCP server issues, and return fabricated results to the model.

Two changes:

  • Bind to 127.0.0.1. The extension connects to ws://localhost:5555 from the
    same machine and the optional ngrok tunnel forwards the HTTP port only, so
    nothing legitimate needs this off-host.
  • Add a verifyClient gate. Loopback alone is not sufficient: a web page the
    user visits can also reach 127.0.0.1. Browsers send Origin on a cross-origin
    WebSocket handshake, so page-initiated connections are rejected while
    extension-scheme origins and local non-browser clients are allowed.

Verified against a running server:

bind address 127.0.0.1:5711 (was *:5711)
loopback + chrome-extension:// ACCEPTED
loopback + no origin ACCEPTED
loopback + https://evil.example REJECTED 403
LAN 10.x.x.x ECONNREFUSED

Not addressed here: the HTTP/SSE server still listens on all interfaces with
Access-Control-Allow-Origin: *. That one needs a separate decision because
remote MCP clients and the tunnel path may depend on it.

Co-Authored-By: Claude noreply@anthropic.com

…e its handshake

The WebSocket server was created as `new WebSocket.Server({ port: WS_PORT })`.
With no `host`, Node listens on all interfaces, so the browser-automation
control channel was reachable from the LAN. The connection handler hands
`chromeExtensionSocket` to whoever connects last, closing the previous socket,
so any reachable peer could evict the real extension, receive every automation
command the MCP server issues, and return fabricated results to the model.

Two changes:

- Bind to 127.0.0.1. The extension connects to ws://localhost:5555 from the
  same machine and the optional ngrok tunnel forwards the HTTP port only, so
  nothing legitimate needs this off-host.
- Add a verifyClient gate. Loopback alone is not sufficient: a web page the
  user visits can also reach 127.0.0.1. Browsers send Origin on a cross-origin
  WebSocket handshake, so page-initiated connections are rejected while
  extension-scheme origins and local non-browser clients are allowed.

Verified against a running server:

  bind address                      127.0.0.1:5711 (was *:5711)
  loopback + chrome-extension://    ACCEPTED
  loopback + no origin              ACCEPTED
  loopback + https://evil.example   REJECTED 403
  LAN 10.x.x.x                      ECONNREFUSED

Not addressed here: the HTTP/SSE server still listens on all interfaces with
`Access-Control-Allow-Origin: *`. That one needs a separate decision because
remote MCP clients and the tunnel path may depend on it.

Co-Authored-By: Claude <noreply@anthropic.com>
@aaronjmars
aaronjmars merged commit d5ea56b into main Jul 20, 2026
2 checks passed
@aaronjmars
aaronjmars deleted the security/ws-loopback-bind branch July 20, 2026 16:49
aaronjmars added a commit that referenced this pull request Jul 20, 2026
… remote access (#55)

Closes #54.

`POST /sse` hands its body straight to handleMCPRequest, which drives the
browser through the extension, and it was reachable two ways it should not have
been: the Express app listened on all interfaces, and CORS answered every
preflight with `Access-Control-Allow-Origin: *`. So any LAN peer could call it,
and any website the user visited could call it cross-origin and read the reply.
#53 closed the equivalent hole on the WebSocket channel; this is the other half.

Three changes, mirroring what #53 established:

- Bind the HTTP server to 127.0.0.1. `ngrok http` runs on this machine and
  forwards to localhost, and the extension fetches /ports from localhost, so
  nothing legitimate needed the wider bind. --http-host= widens it deliberately.
- Gate every request on Origin. Browsers send Origin cross-origin, so page
  requests (http(s), and "null" from a sandbox) are refused 403 while extension
  service workers and non-browser MCP clients pass. CORS now reflects only
  allowed origins instead of `*`, so a page cannot read a response either.
- Require a bearer token once the surface leaves this machine — --tunnel or a
  non-loopback --http-host. Generated and printed at startup, or pinned with
  --token=. The tunnel was previously unauthenticated to anyone who had the URL.

Verified against a running server. Default (local) mode:

  bind address                        127.0.0.1:5556 (was *:5556)
  LAN POST /sse                       ECONNREFUSED
  loopback, no Origin                 200
  loopback, Origin https://evil       403
  loopback, simple req, no preflight  403
  loopback, Origin null               403
  GET /ports, chrome-extension://     200
  preflight from evil page            403, no ACAO header
  preflight from extension            204, ACAO reflects the extension

With --http-host=0.0.0.0:

  LAN POST /sse, no token             401
  LAN POST /sse, wrong token          401
  LAN POST /sse, token prefix         401
  LAN POST /sse, correct token        200
  LAN POST /sse, token + evil Origin  403

The WebSocket channel stays on loopback regardless of --http-host.

Co-authored-by: Claude <noreply@anthropic.com>
aaronjmars added a commit that referenced this pull request Jul 20, 2026
…changes (#57)

* docs: add a changelog and a release checklist that surfaces breaking changes

#55 made `--tunnel` require a bearer token on /sse. That is a real break for
anyone running a tunnelled connector today — it will present as "my ChatGPT
connector stopped working" the moment they upgrade — and right now nothing in the
repo would remind us to say so at tag time. Release notes are hand-written per
release, and `.github/workflows/publish.yml` pushes to npm on a `v*` tag, so the
tag is the point of no return.

Add `.github/CHANGELOG.md`, seeded with an Unreleased section covering everything
landed since v1.1.1 (13 commits): the tunnel token under ⚠️ Breaking with the
migration line spelled out, the three security fixes (#53, #55, #56), the
node-forge/brace-expansion bump (#43), and the dependency and docs maintenance.

Add a Releasing section to CONTRIBUTING that makes reading Unreleased step 1 of
cutting a tag, and states that a ⚠️ Breaking entry means a major bump with the
migration step at the top of the release notes. Also ask PR authors to add their
own entry, so the file stays accurate instead of being reconstructed later.

No code changes.

Co-Authored-By: Claude <noreply@anthropic.com>

* docs: link the adm-zip override to its removal issue (#58)

---------

Co-authored-by: Claude <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant